분산 추적에서 Trace ID와 Span ID 역할

분산 추적에서 Trace ID와 Span ID 역할

한눈에 보기

Trace ID는 여러 프로세스와 네트워크 경계를 지난 하나의 작업 흐름을 묶고, Span ID는 그 안의 개별 작업을 식별한다. 둘을 로그에 붙이는 것만으로 분산 추적이 완성되는 것은 아니다. 부모·자식 관계, traceparent 전파, 비동기 context 보존, sampling과 민감 정보 정책까지 일관되어야 실제 장애에서 끊기지 않는 trace를 얻을 수 있다.

목차

로그만으로 분산 요청을 따라가기 어려운 이유

하나의 애플리케이션만 운영할 때는 시간순 로그만으로 요청을 어느 정도 따라갈 수 있다.

12:00:01 API request started
12:00:01 SELECT article
12:00:01 API request completed

하지만 요청이 gateway, API, worker, 데이터베이스, 외부 서비스로 이어지면 로그가 여러 저장소와 인스턴스에 흩어진다.

flowchart LR
    C[Client] --> G[Gateway]
    G --> A[Article API]
    A --> R[Recommendation API]
    A --> D[(Database)]
    A --> Q[Message Queue]
    Q --> W[Thumbnail Worker]
    R --> X[External Model API]

같은 시각에 수백 개 요청이 들어오면 timestamp와 URL만으로 어떤 로그가 같은 흐름인지 구분하기 어렵다. 서비스 A가 B를 두 번 호출하면 더 복잡해진다. B의 로그에서 어느 호출이 첫 번째인지, 어느 요청이 느렸는지 알기 어렵다.

분산 추적은 각 작업에 식별자와 인과관계를 부여해 이 문제를 다룬다. 중요한 것은 단순히 공통 문자열을 찍는 것이 아니라 전체 흐름을 작업 단위의 그래프로 복원하는 것이다.

Trace와 Span의 관계

Trace는 하나의 요청 또는 사용자 작업이 시스템을 통과한 전체 흐름이다. Span은 그 흐름을 구성하는 하나의 작업 단위다.

Trace: 게시글 상세 페이지 로드
  Span: gateway가 HTTP 요청 수신
    Span: article-api 호출
      Span: article SELECT
      Span: recommendation-api 호출
        Span: 추천 결과 cache GET

각 span에는 보통 다음 정보가 있다.

Trace를 파일 시스템에 비유하면 Trace ID는 한 작업의 최상위 폴더 이름이고 Span ID는 그 안의 각 작업 파일 이름과 비슷하다. 다만 span은 단순 목록이 아니라 부모와 자식 관계를 가진다.

flowchart TD
    S1["server span
GET /articles/:id"] S2["client span
SQL SELECT"] S3["client span
GET recommendation"] S4["server span
GET /recommendations"] S5["client span
cache GET"] S1 --> S2 S1 --> S3 S3 --> S4 S4 --> S5

모든 span이 같은 Trace ID를 공유하고, 각 span은 고유한 Span ID를 가진다. 자식 span에는 부모의 Span ID가 기록된다.

Trace ID와 Span ID는 각각 무엇을 식별할까

W3C Trace Context와 OpenTelemetry의 일반적인 표현에서 Trace ID는 16바이트, Span ID는 8바이트 식별자다. 화면에는 각각 32자리와 16자리의 소문자 16진수로 보인다.

trace_id = 4bf92f3577b34da6a3ce929d0e0e4736
span_id  = 00f067aa0ba902b7
필드 범위 주된 용도
Trace ID 전체 분산 흐름 같은 작업의 모든 span 검색
Span ID 개별 작업 특정 호출·쿼리·처리 단계 식별
Parent Span ID 직전 원인 작업 호출 트리 복원
Trace Flags 전파되는 선택 정보 sampled bit 등
Trace State 공급자별 상태 여러 tracing 시스템 상호 운용

Trace ID가 같다는 것은 같은 trace에 속한다는 뜻이지 모든 작업이 직접적인 부모·자식이라는 뜻은 아니다. 정확한 경로는 Span ID와 Parent Span ID로 만든다.

{
  "name": "GET /articles/:id",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "1111111111111111",
  "parent_span_id": null,
  "duration_ms": 182
}
{
  "name": "SELECT article",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "2222222222222222",
  "parent_span_id": "1111111111111111",
  "duration_ms": 34
}

Trace ID만 로그에 넣으면 관련 로그를 모을 수는 있지만 어느 작업이 어느 호출에서 생겼는지 알기 어렵다. Span ID까지 넣으면 trace 화면의 특정 span과 로그를 직접 연결할 수 있다.

ID에 업무 의미를 넣지 않는다

사용자 ID, 주문 번호, 날짜를 조합해 Trace ID를 직접 만들지 않는다. 충돌 가능성과 개인정보 노출이 생긴다. 표준 SDK의 ID generator를 사용한다.

부모 자식 관계로 호출 구조를 만든다

API A가 API B를 호출할 때 일반적으로 A는 client span을 만들고 B는 server span을 만든다.

sequenceDiagram
    participant C as Client
    participant A as Article API
    participant B as Recommendation API

    C->>A: HTTP request
    activate A
    Note over A: server span A1
    A->>B: HTTP request
    Note over A: client span A2
    activate B
    Note over B: server span B1
parent=A2 B-->>A: HTTP response deactivate B A-->>C: HTTP response deactivate A

A의 client span과 B의 server span은 같은 네트워크 요청을 양쪽에서 관측한 것이다. 따라서 시간이 거의 겹치지만 서로 다른 span이다. 네트워크와 proxy 구간도 포함되므로 duration이 정확히 같지 않을 수 있다.

A client span:  92ms
B server span:  81ms
difference:     serialization + network + proxy + scheduling

하나의 span을 두 서비스가 공유하게 만들면 각 서비스가 기록한 속성과 시간이 섞이고 작업 경계를 표현하기 어렵다. 호출자 client span과 수신자 server span을 구분한다.

Span kind는 보통 다음처럼 작업의 역할을 설명한다.

kind 의미
SERVER 들어온 요청을 처리
CLIENT 외부 서비스나 데이터 저장소 호출
PRODUCER 메시지를 발행
CONSUMER 메시지를 소비
INTERNAL 프로세스 내부 작업

traceparent로 프로세스 경계를 넘는다

프로세스 내부에서 active context를 유지하는 것만으로는 다른 서비스가 같은 Trace ID를 알 수 없다. HTTP 요청에는 표준화된 traceparent 헤더를 사용해 span context를 전달할 수 있다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

형식은 다음과 같다.

version-trace_id-parent_id-trace_flags
00
  └─ version
4bf92f3577b34da6a3ce929d0e0e4736
  └─ trace ID
00f067aa0ba902b7
  └─ 호출자가 보낸 parent ID
01
  └─ trace flags, sampled bit가 켜진 예

수신 서비스는 헤더를 검증하고 context를 추출한 뒤, 같은 Trace ID와 새로운 Span ID로 server span을 만든다. 다음 outgoing 호출에서는 현재 span의 ID를 새 parent-id로 넣는다.

sequenceDiagram
    participant A as Service A
    participant B as Service B
    participant C as Service C

    Note over A: trace=T, span=A1
    A->>B: traceparent(trace=T, parent=A1)
    Note over B: trace=T, span=B1, parent=A1
    B->>C: traceparent(trace=T, parent=B1)
    Note over C: trace=T, span=C1, parent=B1

직접 헤더 문자열을 조립하기보다 OpenTelemetry propagator와 자동 계측을 사용한다. 형식 검증, tracestate, sampled flag, 다른 프로토콜 전파를 직접 구현하면 미묘한 오류가 생기기 쉽다.

외부 입력의 traceparent를 신뢰 정보로 사용하지 않는다

Trace ID는 인증 정보가 아니다. 공격자가 원하는 헤더를 보낼 수 있다. 권한 판단, rate limit key, 고객 데이터 조회에 사용해서는 안 된다.

Request ID와 Trace ID는 같은가

Request ID도 로그를 묶는 데 사용하므로 Trace ID와 혼동하기 쉽다.

구분 Request ID Trace ID
주된 범위 한 HTTP 요청 또는 gateway 요청 여러 서비스에 걸친 전체 흐름
표준 형식 조직마다 다를 수 있음 W3C Trace Context와 호환 가능
호출별 변경 보통 gateway 요청 동안 고정 trace 전체에서 고정
호출 트리 자체로 표현하지 못함 Span ID와 parent 관계로 표현
외부 노출 고객 지원용으로 응답할 수 있음 내부 구조 노출 정책 검토 필요

둘을 반드시 하나로 합칠 필요는 없다. gateway는 고객에게 짧은 Request ID를 반환하고 내부에서는 Trace ID와 매핑할 수 있다.

HTTP/1.1 500 Internal Server Error
X-Request-Id: req_example_7f2a
Content-Type: application/json

{
  "error": "temporary_failure",
  "requestId": "req_example_7f2a"
}

내부 로그에는 둘 다 기록한다.

{
  "message": "request failed",
  "request_id": "req_example_7f2a",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "failure_kind": "dependency"
}

Trace ID를 그대로 사용자에게 보여 줄지는 보안, 지원 프로세스, tracing backend 접근 정책을 고려해 결정한다. 외부 Request ID를 별도로 두면 내부 tracing 공급자를 바꾸어도 고객 지원 계약을 유지하기 쉽다.

Span에는 무엇을 기록해야 할까

좋은 span name은 작업의 종류를 나타내고 값의 종류가 제한되어 있다.

좋은 이름
GET /articles/:id
SELECT articles
thumbnail.generate

피해야 할 이름
GET /articles/924812
SELECT * FROM articles WHERE id = 924812
generate-thumbnail-for-user@example.test

개별 값은 필요할 때 attribute에 넣되 민감성과 카디널리티를 검토한다.

span.setAttribute("http.request.method", "GET");
span.setAttribute("http.route", "/articles/:id");
span.setAttribute("server.address", "api.example.test");
span.setAttribute("deployment.environment.name", "staging");

Span event는 span 안에서 특정 시점에 일어난 일을 기록한다.

span.addEvent("retry.scheduled", {
  "retry.attempt": 2,
  "retry.delay_ms": 200,
});

오류를 기록할 때는 예외와 status를 구분한다.

try {
  await dependency.fetchArticle();
} catch (error) {
  span.recordException(error as Error);
  span.setStatus({
    code: SpanStatusCode.ERROR,
    message: "dependency request failed",
  });
  throw error;
}

모든 비즈니스 결과를 span error로 표시하지는 않는다. 재고 없음이나 검색 결과 없음이 정상 도메인 결과라면 status는 정상으로 두고 제한된 attribute나 metric outcome으로 표현할 수 있다.

span은 로그 저장소가 아니다

요청 본문, 전체 SQL, 응답 JSON을 전부 attribute로 넣으면 비용과 개인정보 위험이 커진다. 원인 분석에 필요한 제한된 속성, event, 로그 링크만 남긴다.

재구성한 OpenTelemetry 계측 예제

다음은 특정 프로젝트의 실제 코드를 옮긴 것이 아니라 TypeScript 서비스에서 OpenTelemetry API를 사용한다는 가정으로 재구성한 예제다.

import {
  SpanKind,
  SpanStatusCode,
  trace,
} from "@opentelemetry/api";

const tracer = trace.getTracer("article-service", "1.0.0");

라우터와 HTTP 클라이언트는 자동 계측이 이미 server/client span을 만들 수 있다. 수동 span은 도메인에서 의미 있는 내부 작업을 추가할 때 사용한다.

type Article = {
  id: string;
  title: string;
};

async function loadArticle(articleId: string): Promise<Article> {
  return tracer.startActiveSpan(
    "article.load",
    {
      kind: SpanKind.INTERNAL,
      attributes: {
        "app.article.lookup_kind": "by_id",
      },
    },
    async (span) => {
      try {
        const article = await articleRepository.findById(articleId);

        if (!article) {
          span.setAttribute("app.article.found", false);
          throw new ArticleNotFoundError();
        }

        span.setAttribute("app.article.found", true);
        span.setStatus({ code: SpanStatusCode.OK });
        return article;
      } catch (error) {
        span.recordException(error as Error);
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "article lookup failed",
        });
        throw error;
      } finally {
        span.end();
      }
    },
  );
}

startActiveSpan callback이 반환되기 전에 비동기 작업을 await하고 finally에서 span을 끝낸다. 너무 일찍 end()하면 실제 작업보다 짧은 span이 생성된다.

// 잘못된 예: Promise가 끝나기 전에 span 종료
const span = tracer.startSpan("article.load");
const promise = articleRepository.findById(articleId);
span.end();
return promise;

outgoing HTTP 호출을 수동으로 계측해야 한다면 현재 context에서 client span을 만들고 propagator로 헤더에 주입한다. 다음 코드는 흐름을 보여 주는 개념 예제다.

import {
  context,
  propagation,
  SpanKind,
  SpanStatusCode,
} from "@opentelemetry/api";

async function fetchRecommendations(articleId: string) {
  return tracer.startActiveSpan(
    "GET recommendation-service/recommendations",
    { kind: SpanKind.CLIENT },
    async (span) => {
      const headers: Record<string, string> = {};
      propagation.inject(context.active(), headers);

      try {
        const response = await fetch(
          `https://recommendation.example.test/recommendations?article=${articleId}`,
          { headers },
        );

        span.setAttribute("http.response.status_code", response.status);

        if (!response.ok) {
          span.setStatus({
            code: SpanStatusCode.ERROR,
            message: `HTTP ${response.status}`,
          });
        }

        return response.json();
      } catch (error) {
        span.recordException(error as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw error;
      } finally {
        span.end();
      }
    },
  );
}

실무에서는 fetch, HTTP 서버, 데이터베이스 드라이버의 공식 instrumentation이 같은 작업을 자동으로 해 주는지 먼저 확인한다. 자동 계측과 수동 계측이 겹치면 같은 호출의 client span이 두 개 생긴다.

비동기 작업에서 context가 끊기는 문제

현재 span은 전역 변수 하나로 보관할 수 없다. 동시에 처리하는 요청들이 서로 덮어쓰기 때문이다.

// 잘못된 예
let currentTraceId: string | undefined;

async function handleRequest(request: Request) {
  currentTraceId = request.headers.get("traceparent") ?? undefined;
  await doAsyncWork();
  logger.info({ currentTraceId }, "done");
}

요청 A가 await 중일 때 요청 B가 들어오면 전역 값이 B의 ID로 바뀐다. 언어별 async context 기능이나 OpenTelemetry context manager가 필요하다.

자동 전파가 끊기기 쉬운 경계는 다음과 같다.

context가 보존되는지 작은 테스트를 만든다.

import { context, trace } from "@opentelemetry/api";
import { expect, it } from "vitest";

it("비동기 callback에서도 active span을 유지한다", async () => {
  await tracer.startActiveSpan("test.root", async (rootSpan) => {
    const expected = rootSpan.spanContext().traceId;

    await new Promise<void>((resolve) => {
      setImmediate(() => {
        const active = trace.getSpan(context.active());
        expect(active?.spanContext().traceId).toBe(expected);
        resolve();
      });
    });

    rootSpan.end();
  });
});

사용하는 런타임과 instrumentation 조합에서 이 테스트가 실패한다면 context manager 초기화 순서와 라이브러리 패치를 확인한다. instrumentation은 대상 모듈을 import하기 전에 등록해야 하는 경우가 많다.

HTTP 동기 호출은 부모·자식 관계가 비교적 명확하다. 메시지 queue에서는 한 메시지가 오랫동안 기다리거나 여러 메시지가 하나의 batch로 합쳐지고, 한 메시지가 여러 consumer로 fan-out될 수 있다.

producer는 메시지에 trace context를 주입할 수 있다.

const messageHeaders: Record<string, string> = {};
propagation.inject(context.active(), messageHeaders);

await queue.publish({
  type: "thumbnail.requested",
  headers: messageHeaders,
  body: {
    imageId: "example-image",
  },
});

consumer가 메시지 하나를 처리하고 인과관계가 직접적이라면 추출한 context를 부모로 새 consumer span을 만들 수 있다.

const parentContext = propagation.extract(
  context.active(),
  message.headers,
);

await context.with(parentContext, async () => {
  await tracer.startActiveSpan(
    "thumbnail.process",
    { kind: SpanKind.CONSUMER },
    async (span) => {
      try {
        await generateThumbnail(message.body);
      } finally {
        span.end();
      }
    },
  );
});

그러나 여러 입력 메시지를 하나로 처리하는 batch에는 부모가 하나가 아니다. 이때 하나를 임의로 부모로 고르면 나머지 인과관계가 사라진다. 새 trace의 batch span에 입력 span들의 link를 추가할 수 있다.

const links = messages
  .map((message) => extractSpanContext(message.headers))
  .filter((spanContext) => spanContext.isValid())
  .map((spanContext) => ({ context: spanContext }));

const batchSpan = tracer.startSpan("search-index.batch", { links });

Span Link는 부모·자식 트리가 아닌 관련성을 표현한다. retry, batch, fan-in, 비동기 workflow에 유용하다.

flowchart LR
    P1[producer span A] -. link .-> B[batch consumer span]
    P2[producer span B] -. link .-> B
    P3[producer span C] -. link .-> B

메시지 payload에 자체 traceId 필드를 임의로 추가하기보다 사용하는 메시지 시스템의 OpenTelemetry semantic conventions와 propagator를 따른다.

Sampling이 만드는 관측의 빈틈

모든 요청의 모든 span을 저장하면 비용이 커진다. Sampling은 어떤 trace를 기록하거나 export할지 선택한다.

방식 결정 시점 장점 한계
Head sampling trace 시작 시 단순하고 비용 예측이 쉬움 나중에 발생한 오류를 모른 채 제외 가능
Tail sampling trace 완료 후 오류·느린 trace를 우선 보존 가능 collector 자원과 지연, 상태 관리 필요
Parent-based 부모 결정을 따름 trace가 서비스 사이에서 일관됨 외부·신뢰 경계 정책 필요

10% head sampling이면 대략 10개 중 1개 trace만 저장된다.

traces started: 100,000
sampling ratio: 0.10
traces recorded: approximately 10,000

그러나 오류가 0.01%인 서비스에서 무작위 1% sampling을 하면 드문 오류 trace를 거의 놓칠 수 있다. tail sampling으로 오류와 높은 latency를 보존하거나, head sampling 비율과 오류 로그 상관관계를 함께 설계한다.

# 특정 collector 설정을 복사한 것이 아닌 정책 개념 예제
tail_sampling:
  decision_wait: 10s
  policies:
    - name: keep-errors
      type: status-code
      status_codes: [ERROR]
    - name: keep-slow-traces
      type: latency
      threshold_ms: 1500
    - name: baseline
      type: probabilistic
      percentage: 5

tail sampling도 만능은 아니다.

trace-flags의 sampled bit는 호출자의 기록 의도를 전달하지만 보안 명령이 아니다. 수신 서비스는 자원 상황과 신뢰 경계에 따라 자체 정책을 적용할 수 있다.

trace 화면에 없다고 요청이 없었던 것은 아니다

sampling, export 실패, context 단절 때문에 trace가 저장되지 않았을 수 있다. 요청 수와 오류율의 기준은 metric을 사용하고 trace는 대표 요청의 원인 조사에 사용한다.

로그 메트릭 Trace를 연결한다

관측 신호마다 잘하는 일이 다르다.

신호 잘 답하는 질문
Metric 얼마나 자주, 언제부터, 어느 범위가 비정상인가
Trace 한 요청의 시간이 어느 작업에서 소요됐는가
Log 그 작업에서 구체적으로 어떤 사건과 데이터가 있었는가

RED 대시보드에서 p99 상승을 발견한 뒤 exemplar로 느린 trace를 열고, span에서 같은 Trace ID와 Span ID를 가진 로그를 찾는 흐름이 이상적이다.

flowchart LR
    M[Metric
p99 상승] --> T[Trace
느린 경로 확인] T --> S[Span
문제 작업 선택] S --> L[Log
구체적 오류와 상태 확인]

active span의 context를 로그 필드에 넣는 예시다.

import { context, trace } from "@opentelemetry/api";

function tracingFields(): Record<string, string> {
  const activeSpan = trace.getSpan(context.active());
  if (!activeSpan) return {};

  const spanContext = activeSpan.spanContext();

  return {
    trace_id: spanContext.traceId,
    span_id: spanContext.spanId,
  };
}

logger.error(
  {
    ...tracingFields(),
    failure_kind: "dependency_timeout",
  },
  "recommendation request failed",
);

로깅 instrumentation이 자동으로 context를 넣어 주면 수동 코드를 중복하지 않는다. JSON field 이름과 tracing backend의 link 설정을 표준화한다.

Metric label에 Trace ID를 넣어서는 안 된다. 요청마다 값이 달라 시계열이 무제한으로 생긴다. 일부 histogram 관측에 exemplar로 Trace ID를 연결하는 기능을 사용하면 낮은 카디널리티 metric과 개별 trace 사이를 안전하게 연결할 수 있다.

보안과 개인정보를 고려한다

Context propagation은 서비스 경계를 넘는 데이터 전달이다. 특히 baggage에는 임의 key-value를 넣을 수 있어 편리하지만 민감 정보를 넣으면 모든 downstream과 외부 서비스로 퍼질 수 있다.

baggage: tenant.plan=pro,experiment.checkout=v2

다음 값은 baggage와 span attribute에 넣지 않는 것을 기본으로 한다.

tenant ID도 개인정보·보안 정책과 카디널리티를 고려해야 한다. 꼭 필요하다면 allow list가 있는 낮은 카디널리티 분류 값으로 바꾸거나 접근 제한된 로그에서만 다룬다.

외부 서비스로 나가는 요청에 내부 traceparent, tracestate, baggage를 그대로 보낼지도 결정해야 한다. 신뢰하지 않는 목적지에는 baggage를 제거하고 필요하면 새 trace 경계를 만든다.

function headersForExternalApi(): Record<string, string> {
  const headers: Record<string, string> = {
    "content-type": "application/json",
  };

  // 내부 baggage는 주입하지 않는 정책 예시
  propagation.inject(context.active(), headers, {
    set(carrier, key, value) {
      if (key.toLowerCase() === "baggage") return;
      carrier[key] = value;
    },
  });

  return headers;
}

위 코드는 정책 개념을 보이는 예시다. 실제 구현은 propagator 구성과 egress proxy 정책으로 중앙화하는 편이 안전하다.

추적 데이터 저장소 자체도 민감한 운영 정보다. 서비스 구조, 데이터베이스 문장, 오류 메시지, 사용자 행동이 들어갈 수 있으므로 접근 제어, 보존 기간, 삭제 정책, export 암호화를 적용한다.

끊어진 Trace를 점검하는 방법

trace 화면에서 여러 개의 root span이 생기거나 서비스 사이 연결이 끊기면 다음 순서로 확인한다.

1. outgoing 요청에 헤더가 있는지 본다

curl -v \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  'https://example.test/diagnostics/trace'

실제 운영 endpoint가 아니라 안전한 스테이징 진단 경로에서 확인한다. proxy가 traceparent를 제거하거나 중복 생성하는지 본다.

2. 수신 서비스의 server span parent를 본다

{
  "service.name": "recommendation-service",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "3333333333333333",
  "parent_span_id": "00f067aa0ba902b7"
}

Trace ID가 새로 만들어졌다면 extract가 실패했거나 잘못된 헤더로 판단됐을 수 있다.

3. SDK 초기화 순서를 확인한다

HTTP 라이브러리를 먼저 import한 뒤 instrumentation을 등록하면 monkey patch가 적용되지 않는 런타임이 있다. telemetry bootstrap을 application import보다 먼저 실행한다.

// bootstrap.ts
await startTelemetry();
await import("./application.js");

4. 비동기 context를 테스트한다

timer, event emitter, queue callback, worker thread마다 active span이 보존되는지 테스트한다. 특정 라이브러리 callback에서만 끊기면 명시적으로 context를 bind한다.

5. sampling과 export 상태를 확인한다

otelcol_receiver_accepted_spans
otelcol_processor_dropped_spans
otelcol_exporter_send_failed_spans
otelcol_exporter_queue_size

이름은 배포한 collector 버전에 따라 확인한다. 애플리케이션에서 생성됐지만 collector queue에서 drop될 수 있다.

6. 시계 동기화와 span 종료를 확인한다

서비스 시계가 어긋나면 child span이 부모보다 먼저 시작한 것처럼 보인다. 종료되지 않은 span은 export가 늦거나 duration이 이상해진다. NTP 상태와 shutdown 시 provider flush를 확인한다.

가장 작은 통합 테스트

스테이징에서 gateway → API A → API B → queue → worker 경로를 한 번 호출하고, 같은 Trace ID 또는 올바른 Span Link로 모든 구간이 연결되는지 배포 검증에 포함한다.

마무리

Trace ID는 하나의 분산 작업을 찾는 검색 키이고, Span ID는 그 안의 개별 작업과 부모·자식 관계를 표현한다. 하지만 ID를 생성해 로그에 붙이는 것만으로는 호출 그래프가 만들어지지 않는다.

분산 추적의 핵심은 식별자 자체보다 context가 작업 경계를 따라 정확히 전파되고, 각 span이 올바른 부모와 제한된 속성을 갖는 데 있다.

운영 가능한 trace를 만들려면 다음을 함께 설계한다.

  1. 표준 SDK로 Trace ID와 Span ID를 생성한다.
  2. HTTP에는 W3C traceparent와 propagator를 사용한다.
  3. server, client, producer, consumer span 경계를 구분한다.
  4. async context가 callback과 worker 경계에서 유지되는지 테스트한다.
  5. batch와 fan-in은 Span Link를 검토한다.
  6. metric에는 Trace ID를 label로 넣지 않고 exemplar를 사용한다.
  7. 로그에 Trace ID와 Span ID를 구조화 필드로 연결한다.
  8. 오류와 느린 trace를 보존할 sampling 정책을 둔다.
  9. baggage와 span attribute에서 민감 정보를 제거한다.
  10. collector drop과 export 실패도 모니터링한다.

분산 추적은 모든 요청을 완벽히 기록하는 시스템이 아니라, metric에서 발견한 이상을 실제 요청의 인과관계로 좁혀 가는 도구다. trace가 끊기지 않는다는 가정을 배포 때마다 검증해야 장애 순간에도 믿고 사용할 수 있다.

참고 자료

관련 노트